iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
AI Engineering

從 MCP 到專屬 Agentic 模型:30 天走完一條可評測、可微調、可自架的 AI Agent 模型與服務製作流程系列 第 28

[ Enterprise Architecture ] Day 28 — 企業級生產落地防禦指南:不依賴模型判斷的那幾道防線

  • 分享至 

  • xImage
  •  

Day 28 今日地圖:今天在整條閉環的位置、承接與產出

I. 前言:測試環境重跑一次就好,生產環境不行

昨天把 Agent 的概念翻譯回工程語言,其中最關鍵的一句是:dispatch 變成機率性的。

在測試環境裡,這件事的代價是重跑一次。在生產環境裡,代價可能是誤撤了同仁已核准的年假、或是把一張還沒簽完的假單標記成已休。

而這些操作在資料層都是真的。 Day 3 特地把 cancel_approved_leave 設計成真的會刪資料,就是為了讓這個風險在整個系列中都是具體的,而不是理論上的。

要讓企業願意把 Agent 放上生產,需要的不是「更好的 Prompt」,而是一套縱深防禦(Defense in Depth):任何一層失效時,還有下一層擋著。

而在開始之前,先給一條貫穿今天的判準 —— 它其實在 Day 4 的四道防線就出現過:

可靠的防線都不依賴模型的判斷。

每介紹一道防線,都會回頭用這條準則檢驗它。

以下的內容,會依序說明四層防禦的實作方式,接著討論它們各自的可靠度,最後處理一個容易被忽略的問題:防線本身也會出錯。

II. 四層防禦的全貌

企業級 Agent 生產環境多層防禦體系

表格:層、位置、攔截什麼、依賴模型判斷?

L2 與 L3 是骨幹,因為它們完全不依賴模型的自覺。 L1 與 L4 有價值,但它們是輔助。

III. L1・輸入防護

在使用者輸入抵達 Agent 之前先過濾。

三件要做的事

第一,Prompt Injection 的初步過濾。 攔截「忽略先前的指令」「以管理員身分執行」這類明顯的樣式。

第二,PII 遮蔽。 身分證號、信用卡號、電話 —— 這些不該進入模型上下文,更不該進入日誌(Day 11 的 scrub() 是同一件事的另一半)。

第三,意圖邊界檢查。 差勤助手收到「幫我寫一首詩」,直接擋掉比讓模型處理更省。

實作位置

Google ADK 的 Plugin 正好提供了掛載點。Day 11 說觀測用的 plugin 一律回傳 None,而防禦用的 plugin 恰恰相反 —— 回傳非 None 就是攔截

from google.adk.plugins import BasePlugin
from google.genai import types

BLOCK_PATTERNS = [
    "忽略先前", "ignore previous", "以管理員", "act as admin",
]


class InputGuardPlugin(BasePlugin):
    def __init__(self):
        super().__init__(name="input_guard")

    async def on_user_message_callback(self, *, invocation_context, user_message):
        text = "".join(p.text or "" for p in (user_message.parts or []))

        for pattern in BLOCK_PATTERNS:
            if pattern in text.lower():
                # 回傳非 None → 取代使用者訊息
                return types.Content(
                    role="user",
                    parts=[types.Part(text="[已攔截:輸入包含可疑指令樣式]")],
                )

        return None      # 放行

若要直接中止整個執行,用 before_run_callback —— 它回傳非 None中止 runner 並直接回傳該內容

誠實評估:這一層擋不住多少

關鍵字比對只擋得住最直白的攻擊。

真正的注入不會寫「忽略先前的指令」。它會用編碼、用不同語言、用看似無害的敘述,或者根本不在使用者輸入裡 —— 而是在工具回傳的資料裡

Day 4 說明過那個更難處理的情境:惡意 MCP Server 把注入指令藏在工具描述中。而在生產環境還有第三種:假單的備註欄位裡有人寫了一段注入文字,而那段文字會隨著 search_leaves 的結果進入模型上下文。

所以 L1 的定位是「降低雜訊」,不是「保證安全」。 真正的保證要靠 L2 與 L3。

IV. L2・執行攔截:破壞性操作走 Elicitation

這是整個系列的核心設計,Day 3 埋下、Day 7 接上、Day 24 驗證過它在無框架環境下依然生效。

兩條必須守住的規則

規則一:任何破壞性操作都必須觸發確認。

撤銷、撤回、批次核准 —— 這些操作走 ctx.elicit(),由使用者決定。模型不在這條決策鏈上。

規則二:decline 必須是徹底的終止。

這是四個難點裡最難的一個。模型被拒絕之後,最常見的失敗是「換個方式再試」—— 用 schedule_handover 排一個延遲任務去做同一件事。

在生產環境裡,這種行為的後果是:使用者以為自己拒絕了,但操作還是發生了。

用 Plugin 補上第二道保險

Elicitation 已經是 Server 端的機制,但可以在 Agent 端再加一層 —— 記錄被拒絕的意圖,之後阻擋等效的操作:

WRITE_TOOLS = {"update_leave_status", "cancel_approved_leave", "schedule_handover", "withdraw_leave"}


class DeclineGuardPlugin(BasePlugin):
    def __init__(self):
        super().__init__(name="decline_guard")
        self._declined: dict[str, bool] = {}

    async def before_tool_callback(self, *, tool, tool_args, tool_context):
        inv = tool_context.invocation_id

        if self._declined.get(inv) and tool.name in WRITE_TOOLS:
            # 回傳 dict → 直接跳過工具執行
            return {
                "error": "使用者已拒絕本次破壞性操作,不得改用其他寫入工具達成。",
            }
        return None

    async def after_tool_callback(self, *, tool, tool_args, tool_context, result):
        if isinstance(result, dict) and result.get("elicitation") == "declined":
            self._declined[tool_context.invocation_id] = True
        return None

注意 before_tool_callback 回傳 dict 的語意:直接跳過工具執行,並把這個 dict 當成結果回給模型。 這正是 Day 11 提醒過的「Plugin 不只能看,還能改」—— 在觀測情境要克制,在防禦情境正好用得上。

而回傳的錯誤訊息寫得明確,也是刻意的:它會進入模型的上下文,成為它下一步的依據。 這與 Day 3 的錯誤訊息設計是同一個原理。

用這條準則檢驗

這一層不依賴模型判斷嗎? 是的。

即使模型完全被注入的指令說服、決心要執行 cancel_approved_leave,使用者依然會看到確認表單。而拒絕之後的繞道,也被 Plugin 在工具層擋下 —— 不是靠 instruction 叮嚀它不要這麼做。

V. L3・權限隔離:最小權限原則

讀寫分離

Day 10 的雙 Agent 架構就是這一層:查詢 Agent 的 tool_filter 裡只有唯讀工具。

McpToolset(
    connection_params=...,
    tool_filter=["search_leaves", "get_leave", "list_employees"],
)

這道防線的可靠度是四層裡最高的,理由很簡單:工具不在清單裡,就等於不存在。 沒有任何 Prompt 技巧能讓模型呼叫一個它看不到的工具。

資源限額

死迴圈在生產環境是實際會發生的,而它燒的是真的錢:

表格:限額、建議做法

最後一項值得展開。Day 11 提過 Event 繼承自 LlmResponse,帶有 usage_metadata —— 這讓成本監控可以直接掛在 on_event_callback 上:

class BudgetGuardPlugin(BasePlugin):
    def __init__(self, max_tokens: int = 50_000):
        super().__init__(name="budget_guard")
        self.max_tokens = max_tokens
        self._used: dict[str, int] = {}

    async def on_event_callback(self, *, invocation_context, event):
        usage = getattr(event, "usage_metadata", None)
        if usage and usage.total_token_count:
            inv = event.invocation_id
            self._used[inv] = self._used.get(inv, 0) + usage.total_token_count
            if self._used[inv] > self.max_tokens:
                logger.warning("invocation %s 超出 token 預算", inv)
        return None

服務層的隔離

再往下一層:MCP Server 連資料庫用的帳號,本身就該是受限的。

如果 Leave Copilot 只需要讀寫假單表,那它的資料庫帳號就不該有 DROP TABLE 的權限。這一層與 AI 完全無關,是基本的系統設計 —— 但它是最後一道、也是最硬的防線。

VI. L4・輸出檢驗與降級

結構化驗證

如果 Agent 的輸出會被下游程式消費,就必須驗證格式。用 Pydantic 或 JSON Schema 檢查,不通過就要求重試或走降級路徑。

出站的資料檢查

一個容易被忽略的方向:檢查輸出裡有沒有不該外流的東西。

模型可能在回答裡帶上內部主機名稱、完整的連線字串、或是其他使用者的假單內容。輸出檢驗應該把這些遮蔽掉。

降級策略(Failover)

自架的推論服務會掛。降級的順序建議是:

1. 本地微調模型(主要)
      ↓ 逾時或異常
2. 商業 API(備援,成本較高但可用性佳)
      ↓ 仍然失敗
3. 固定規則腳本(只處理最常見的幾種請求)
      ↓ 仍然失敗
4. 明確告知使用者服務暫時不可用,並提供人工管道

第四層不能省。 一個壞掉但還在回話的 Agent,比一個明確說「我現在不能用」的 Agent 危險得多 —— 因為使用者會相信它。

而降級到商業 API 時要注意一件事:它沒有經過我們的微調。 那條「先查再改」的規則在商業模型上是機率性的。所以降級路徑上的 L2、L3 防線更不能少 —— 這也再次說明為什麼那兩層不該依賴模型。

VII. 四道防線的可靠度並不相同

回到開頭那條準則,把四層攤開檢驗:

表格:層、依賴模型判斷?、繞得過嗎、定位

這張表可以直接當成投資決策的依據。 如果時間有限,先把 L2 與 L3 做扎實,再回頭補 L1 與 L4。

反過來說,最不值得投資的是「在 instruction 裡寫更多叮嚀」—— Day 2 與 Day 4 都說過原因:那些文字與注入的指令處在同一個上下文裡,權重相當。

VIII. 防線本身也會出錯

最後一個容易被忽略的問題:加了四層防禦之後,系統的失敗模式變多了。

第一,誤攔(False Positive)。 輸入防護的關鍵字太寬鬆,正常請求被擋掉。使用者的感受是「這東西很難用」,而且通常不會回報,只會停止使用。

第二,防線之間互相衝突。 L2 記錄了 decline、L3 的 tool_filter 又擋掉了另一個工具,最後模型陷入「什麼都不能做」的狀態,回一句意義不明的話。

第三,防線讓問題變得難以診斷。 一個請求失敗了,是模型錯、L1 擋了、L2 攔了,還是 L3 沒給工具?

這三個問題的解法是同一個:Day 11 的紀錄機制。 每一次攔截都必須留下結構化的紀錄:

self._write(inv, {
    "kind": "guard_block",
    "layer": "L2",
    "tool": tool.name,
    "reason": "user_declined_destructive_op",
})

沒有這些紀錄,四層防禦就變成一個黑盒子。 而定期檢視攔截紀錄,也是唯一能發現「誤攔」的方式。

IX. 結語

生產環境的可靠性不是靠更好的 Prompt 得來的,而是靠一組不依賴模型判斷的機制堆出來的。

總結來說,今天有三個重點值得帶走:

  • L2 與 L3 是骨幹,因為它們繞不過去: Elicitation 讓破壞性操作的決定權回到使用者手上,tool_filter 讓未授權的能力根本不存在。相對地,輸入過濾只擋得住最直白的攻擊 —— 換個說法、換個語言,或是把注入寫進假單備註欄,它就失效了。
  • 降級路徑上的防線更不能少: 備援的商業 API 沒有經過微調,那條「先查再改」的規則在它身上是機率性的。而「一個壞掉但還在回話的 Agent」比明確說自己不可用的 Agent 危險得多。
  • 防禦本身會製造新的失敗模式,而且多半是靜默的: 誤攔不會被回報,只會讓人默默不再使用;防線互相衝突會讓模型陷入什麼都做不了的狀態。每一次攔截都必須留下結構化紀錄,否則整套防禦就是一個無法診斷的黑盒子。

明天是全系列最後一個進階主題:自我進化閉環。到目前為止,Day 11 的紀錄、Day 13 的評測、Day 15 的資料萃取、Day 20 的訓練 —— 這幾件事都是手動串起來的。明天要談的是,怎麼把這條鏈路自動化,讓生產環境的真實軌跡持續回流成為下一版模型的養分。

Day 28 Cheat Sheet:指令、參數與容易踩的地方


參考來源

  • MCP 規範・Elicitation——accept / decline / cancel 三態語意
  • MCP 規範——工具描述應視為不可信輸入
  • google/adk/plugins/base_plugin.py(google-adk 2.7.1)——on_user_message_callbackbefore_run_callbackbefore_tool_callback 的攔截語意
  • google/adk/models/llm_response.py——usage_metadata 欄位
  • Google ADK・MCP Tools——tool_filter
  • OWASP・Top 10 for LLM Applications——Prompt Injection 與過度授權的風險分類

查證日期:2026-08-24


I am Simon

大家好,我是 Simon 劉育維,是一位 AI 領域解決方案專家,目前也擔任 Google Cloud AI 領域開發者專家 (GDE),期待能夠幫助企業導入人工智慧相關技術解決問題。如果這篇文章對您有幫助,歡迎在我的 Linkedin 上留言提供意見,並與我一起討論有關人工智慧的主題,期待能夠對大家有所幫助!

我的個人部落格資訊:https://medium.com/@simon3458


上一篇
[ Agent Architecture ] Day 27 — Agent 與軟體工程名詞大對照:把擬人化詞彙翻譯回工程語言
下一篇
[ Enterprise Architecture ] Day 29 — 自我進化閉環:把手動的那條鏈路接起來
系列文
從 MCP 到專屬 Agentic 模型:30 天走完一條可評測、可微調、可自架的 AI Agent 模型與服務製作流程30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言